/**
 * @file Broadcast API helpers.
 * @author Johan Nordberg <code@johan-nordberg.com>
 * @license
 * Copyright (c) 2017 Johan Nordberg. All Rights Reserved.
 *
 * Redistribution and use in source and binary forms, with or without modification,
 * are permitted provided that the following conditions are met:
 *
 *  1. Redistribution of source code must retain the above copyright notice, this
 *     list of conditions and the following disclaimer.
 *
 *  2. Redistribution in binary form must reproduce the above copyright notice,
 *     this list of conditions and the following disclaimer in the documentation
 *     and/or other materials provided with the distribution.
 *
 *  3. Neither the name of the copyright holder nor the names of its contributors
 *     may be used to endorse or promote products derived from this software without
 *     specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
 * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
 * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
 * IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
 * INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
 * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
 * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
 * OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
 * OF THE POSSIBILITY OF SUCH DAMAGE.
 *
 * You acknowledge that this software is not designed, licensed or intended for use
 * in the design, construction, operation or maintenance of any military facility.
 */

import * as assert from 'assert'

import { Authority, AuthorityType } from '../chain/account'
import { Asset } from '../chain/asset'
import {
  AccountUpdateOperation,
  ClaimAccountOperation,
  CommentOperation,
  CommentOptionsOperation,
  CreateClaimedAccountOperation,
  CustomJsonOperation,
  DelegateVestingSharesOperation,
  Operation,
  TransferOperation,
  VoteOperation
} from '../chain/operation'
import {
  SignedTransaction,
  Transaction,
  TransactionConfirmation
} from '../chain/transaction'
import { Client } from './../client'
import { cryptoUtils, PrivateKey, PublicKey } from './../crypto'

export interface CreateAccountOptions {
  /**
   * Username for the new account.
   */
  username: string
  /**
   * Password for the new account, if set, all keys will be derived from this.
   */
  password?: string
  /**
   * Account authorities, used to manually set account keys.
   * Can not be used together with the password option.
   */
  auths?: {
    owner: AuthorityType | string | PublicKey
    active: AuthorityType | string | PublicKey
    posting: AuthorityType | string | PublicKey
    memoKey: PublicKey | string
  }
  /**
   * Creator account, fee will be deducted from this and the key to sign
   * the transaction must be the creators active key.
   */
  creator: string
  /**
   * Account creation fee. If omitted fee will be set to lowest possible.
   */
  fee?: string | Asset | number
  /**
   * Account delegation, amount of VESTS to delegate to the new account.
   * If omitted the delegation amount will be the lowest possible based
   * on the fee. Can be set to zero to disable delegation.
   */
  delegation?: string | Asset | number
  /**
   * Optional account meta-data.
   */
  metadata?: { [key: string]: any }
}

export class BroadcastAPI {
  /**
   * How many milliseconds in the future to set the expiry time to when
   * broadcasting a transaction, defaults to 1 minute.
   */
  public expireTime = 60 * 1000

  constructor(readonly client: Client) {}

  /**
   * Broadcast a comment, also used to create a new top level post.
   * @param comment The comment/post.
   * @param key Private posting key of comment author.
   */
  public async comment(comment: CommentOperation[1], key: PrivateKey) {
    const op: Operation = ['comment', comment]
    return this.sendOperations([op], key)
  }

  /**
   * Broadcast a comment and set the options.
   * @param comment The comment/post.
   * @param options The comment/post options.
   * @param key Private posting key of comment author.
   */
  public async commentWithOptions(
    comment: CommentOperation[1],
    options: CommentOptionsOperation[1],
    key: PrivateKey
  ) {
    const ops: Operation[] = [
      ['comment', comment],
      ['comment_options', options]
    ]
    return this.sendOperations(ops, key)
  }

  /**
   * Broadcast a vote.
   * @param vote The vote to send.
   * @param key Private posting key of the voter.
   */
  public async vote(vote: VoteOperation[1], key: PrivateKey) {
    const op: Operation = ['vote', vote]
    return this.sendOperations([op], key)
  }

  /**
   * Broadcast a transfer.
   * @param data The transfer operation payload.
   * @param key Private active key of sender.
   */
  public async transfer(data: TransferOperation[1], key: PrivateKey) {
    const op: Operation = ['transfer', data]
    return this.sendOperations([op], key)
  }

  /**
   * Broadcast custom JSON.
   * @param data The custom_json operation payload.
   * @param key Private posting or active key.
   */
  public async json(data: CustomJsonOperation[1], key: PrivateKey) {
    const op: Operation = ['custom_json', data]
    return this.sendOperations([op], key)
  }

  /**
   * Create a new account on testnet.
   * @param options New account options.
   * @param key Private active key of account creator.
   */
  public async createTestAccount(
    options: CreateAccountOptions,
    key: PrivateKey
  ) {
    assert(
      global.hasOwnProperty('it'),
      'helper to be used only for mocha tests'
    )

    const { username, metadata, creator } = options

    const prefix = this.client.addressPrefix
    let owner: Authority,
      active: Authority,
      posting: Authority,
      memo_key: PublicKey
    if (options.password) {
      const ownerKey = PrivateKey.fromLogin(
        username,
        options.password,
        'owner'
      ).createPublic(prefix)
      owner = Authority.from(ownerKey)
      const activeKey = PrivateKey.fromLogin(
        username,
        options.password,
        'active'
      ).createPublic(prefix)
      active = Authority.from(activeKey)
      const postingKey = PrivateKey.fromLogin(
        username,
        options.password,
        'posting'
      ).createPublic(prefix)
      posting = Authority.from(postingKey)
      memo_key = PrivateKey.fromLogin(
        username,
        options.password,
        'memo'
      ).createPublic(prefix)
    } else if (options.auths) {
      owner = Authority.from(options.auths.owner)
      active = Authority.from(options.auths.active)
      posting = Authority.from(options.auths.posting)
      memo_key = PublicKey.from(options.auths.memoKey)
    } else {
      throw new Error('Must specify either password or auths')
    }

    let { fee, delegation } = options

    delegation = Asset.from(delegation || 0, 'VESTS')
    fee = Asset.from(fee || 0, 'TESTS')

    if (fee.amount > 0) {
      const chainProps = await this.client.database.getChainProperties()
      const creationFee = Asset.from(chainProps.account_creation_fee)
      if (fee.amount !== creationFee.amount) {
        throw new Error('Fee must be exactly ' + creationFee.toString())
      }
    }

    const claim_op: ClaimAccountOperation = [
      'claim_account',
      {
        creator,
        extensions: [],
        fee
      }
    ]

    const create_op: CreateClaimedAccountOperation = [
      'create_claimed_account',
      {
        active,
        creator,
        extensions: [],
        json_metadata: metadata ? JSON.stringify(metadata) : '',
        memo_key,
        new_account_name: username,
        owner,
        posting
      }
    ]

    const ops: any[] = [claim_op, create_op]

    if (delegation.amount > 0) {
      const delegate_op: DelegateVestingSharesOperation = [
        'delegate_vesting_shares',
        {
          delegatee: username,
          delegator: creator,
          vesting_shares: delegation
        }
      ]
      ops.push(delegate_op)
    }

    return this.sendOperations(ops, key)
  }

  /**
   * Update account.
   * @param data The account_update payload.
   * @param key The private key of the account affected, should be the corresponding
   *            key level or higher for updating account authorities.
   */
  public async updateAccount(data: AccountUpdateOperation[1], key: PrivateKey) {
    const op: Operation = ['account_update', data]
    return this.sendOperations([op], key)
  }

  /**
   * Delegate vesting shares from one account to the other. The vesting shares are still owned
   * by the original account, but content voting rights and bandwidth allocation are transferred
   * to the receiving account. This sets the delegation to `vesting_shares`, increasing it or
   * decreasing it as needed. (i.e. a delegation of 0 removes the delegation)
   *
   * When a delegation is removed the shares are placed in limbo for a week to prevent a satoshi
   * of VESTS from voting on the same content twice.
   *
   * @param options Delegation options.
   * @param key Private active key of the delegator.
   */
  public async delegateVestingShares(
    options: DelegateVestingSharesOperation[1],
    key: PrivateKey
  ) {
    const op: Operation = ['delegate_vesting_shares', options]
    return this.sendOperations([op], key)
  }

  /**
   * Sign and broadcast transaction with operations to the network. Throws if the transaction expires.
   * @param operations List of operations to send.
   * @param key Private key(s) used to sign transaction.
   */
  public async sendOperations(
    operations: Operation[],
    key: PrivateKey | PrivateKey[]
  ): Promise<TransactionConfirmation> {
    const props = await this.client.database.getDynamicGlobalProperties()

    const ref_block_num = props.head_block_number & 0xffff
    const ref_block_prefix = Buffer.from(
      props.head_block_id,
      'hex'
    ).readUInt32LE(4)
    const expiration = new Date(
      new Date(props.time + 'Z').getTime() + this.expireTime
    )
      .toISOString()
      .slice(0, -5)
    const extensions = []

    const tx: Transaction = {
      expiration,
      extensions,
      operations,
      ref_block_num,
      ref_block_prefix
    }

    const result = await this.send(this.sign(tx, key))
    assert(result.expired === false, 'transaction expired')

    return result
  }

  /**
   * Sign transaction with operations to the network. Throws if the transaction expires.
   * @param operations List of operations to send.
   * @param key Private key(s) used to sign transaction.
   */
  public async signOperations(
    operations: Operation[],
    key: PrivateKey | PrivateKey[]
  ): Promise<SignedTransaction> {
    const props = await this.client.database.getDynamicGlobalProperties()

    const ref_block_num = props.head_block_number & 0xffff
    const ref_block_prefix = Buffer.from(
      props.head_block_id,
      'hex'
    ).readUInt32LE(4)
    const expiration = new Date(
      new Date(props.time + 'Z').getTime() + this.expireTime
    )
      .toISOString()
      .slice(0, -5)
    const extensions = []

    const tx: Transaction = {
      expiration,
      extensions,
      operations,
      ref_block_num,
      ref_block_prefix
    }

    const result = this.sign(tx, key)

    return result
  }

  /**
   * Sign a transaction with key(s).
   */
  public sign(
    transaction: Transaction,
    key: PrivateKey | PrivateKey[]
  ): SignedTransaction {
    return cryptoUtils.signTransaction(transaction, key, this.client.chainId)
  }

  /**
   * Broadcast a signed transaction to the network.
   */
  public async send(
    transaction: SignedTransaction
  ): Promise<TransactionConfirmation> {
    return this.call('broadcast_transaction_synchronous', [transaction])
  }

  /**
   * Convenience for calling `condenser_api`.
   */
  public call(method: string, params?: any[]) {
    return this.client.call('condenser_api', method, params)
  }
}
